Skip to content

[mono-move] Chained field access fusion#20249

Open
vineethk wants to merge 1 commit into
mainfrom
vk/chained-field-fusion
Open

[mono-move] Chained field access fusion#20249
vineethk wants to merge 1 commit into
mainfrom
vk/chained-field-fusion

Conversation

@vineethk

@vineethk vineethk commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Chained Field Access Fusion

Overview

This PR teaches the mono-move specializer to recognize chains of struct field borrows — patterns like &s.a.b.c followed by a read or write — and collapse each chain into a single fused instruction. Where the interpreter previously executed one micro-op per field step (each computing an address, materializing a reference, and dispatching back through the run loop), a fused chain now executes as one micro-op that folds the entire path into a single precomputed offset.

Main Changes

New fused IR instructions

The stackless IR gains a family of field chain instructions covering the useful combinations of root (a local, or an existing reference) and terminal action (borrow immutably, borrow mutably, read the value out, or write a value in). Each carries the full field path, so downstream phases see the whole access as one unit. Borrow chains deliberately preserve the immutable/mutable distinction, because a mutable borrow of a local creates a hidden-write channel that later optimization passes (coalescing, copy propagation) must be able to see.

The fusion pass

A new pass runs during destacking, before slot allocation. It scans each block for runs of single-use field borrows that feed directly into one another and ends each run at its terminal use. Key properties:

  • Single-use discipline — an intermediate reference consumed by anything other than the next link breaks the chain, so no observable reference ever disappears.
  • Gap tolerance with terminal sinking — unrelated instructions may sit between the chain and its terminal; the fused instruction is placed at the terminal's position. This is sound for struct chains because the intermediate steps are pure address computations with no observable effects.
  • Enum exclusion — enum variant-field steps are excluded from fusion, because each step carries a tag check that can abort; reordering or merging those checks would change abort behavior.
  • Linear-time operation — the pass rewrites blocks with a lazy placement plan and in-place compaction, keeping expected cost proportional to block size.

Enum variant-field access rework

Enum field access micro-ops were restructured into two tiers:

  • A uniform fast path: when a field lives at the same offset in every variant, the access needs no tag inspection at all and is emitted as a plain heap read/write at a static offset — the same shape as ordinary struct access.
  • A tag-dispatched path: otherwise, the micro-op carries a per-tag offset table; at runtime the tag selects the offset, and a missing entry aborts with a variant mismatch. This replaced an older scratch-slot-plus-dereference lowering for divergent-offset fields, eliminating the scratch mechanism entirely.

All offsets are precomputed as object-relative (the enum tag header is folded in at construction), so the interpreter does no offset arithmetic beyond a single add.

Runtime and gas

  • The interpreter gains straightforward handlers for the new micro-ops, sharing one small helper for the tag-dispatch prologue.
  • A latent undefined-behavior issue was fixed along the way: an 8-byte move micro-op assumed 8-byte alignment, but size-8 aggregates can legally sit at 4-aligned offsets; the accesses are now explicitly unaligned (free on the targeted architectures).

Testing

  • New differential tests cover chain fusion end-to-end, gap-tolerant terminal sinking, divergent enum writes (including the abort-on-wrong-variant paths), and abort-ordering behavior that justifies the enum exclusion.
  • All existing differential and unit suites pass with the fusion enabled; golden outputs were regenerated to reflect the fused instruction streams.

Note

Medium Risk
Touches VM interpreter memory access, enum tag dispatch semantics, and optimizer soundness around hidden writes through mut borrows; behavior is heavily regression-tested but errors would affect execution correctness.

Overview
Adds pre-slot-allocation fusion that turns depth-≥2 runs of single-use struct field borrows (local or ref roots, read/write/borrow terminals, optional gap before the terminal) into *FieldChain stackless instructions, then lowers them with summed offsets via shared helpers. Gas meters chains as one access by terminal field size, not depth.

Enum variant fields are split into uniform HeapReadOffset / HeapWriteOffset vs tag-dispatched Enum*VariantFieldByTag (shared variant_field_loc in the interpreter). Divergent read/write no longer use a 16-byte scratch fat pointer; variant_field_scratch frame reservation is removed.

Runtime fix: Move8 uses unaligned 8-byte copies so misaligned size-8 aggregates are valid.

Copy propagation / destack analysis use mut_local_borrow_target (including MutBorrowLocalFieldChain). New differential tests cover struct chains, enum divergent paths, abort order, and generics.

Reviewed by Cursor Bugbot for commit bd474d4. Bugbot is set up for automated code reviews on this repo. Configure here.

vineethk commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@vineethk vineethk changed the title Implement chained field access fusion [mono-move] Chained field access fusion Jul 22, 2026
@vineethk
vineethk force-pushed the vk/chained-field-fusion branch 2 times, most recently from fceccc9 to d55336f Compare July 22, 2026 19:06
@vineethk
vineethk marked this pull request as ready for review July 22, 2026 19:07
@github-actions

Copy link
Copy Markdown
Contributor

mono-move benchmark gate

1 regression(s) beyond ±3% noise band

2 ok · 4 improved · 0 new · 0 absent (threshold T = ±3%, criterion mean CI vs main)

Benchmark mean Δ 95% CI median (main → PR) Verdict
fib/mono +1.6% [+1.4%, +1.9%] 6.87ms → 6.96ms ok
nested_loop/mono -19.6% [-20.4%, -18.9%] 10.42ms → 8.83ms improved
merge_sort/mono -4.5% [-5.1%, -3.9%] 1.03ms → 975.32µs improved
bst/mono -9.9% [-11.1%, -8.9%] 3.45ms → 3.13ms improved
match_sum/mono +1.6% [+1.1%, +2.0%] 23.63ms → 24.06ms ok
int_arith_loop/mono_u64 -4.2% [-5.3%, -3.1%] 171.71µs → 171.37µs improved
int_arith_loop/mono_i64 +7.8% [+7.5%, +8.2%] 423.35µs → 459.17µs regression

Improvements are not failures. main rebaselines on merge, so the next PR compares against the faster code automatically.

pub(crate) fn mut_local_borrow_target(instr: &Instr) -> Option<Slot> {
if let Instr::MutBorrowLoc(_, local)
| Instr::MutBorrowLocField(_, _, _, local)
| Instr::MutBorrowLocalFieldChain(_, _, local) = instr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is safer to make this an exhaustive match?

| Instr::WriteLocalField(owner, _, _, _) => Some((*owner, NominalKind::Struct)),

// A struct chain's first owner contains every later owner as an inline
// field, so discovering it transitively lays out the whole path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a silent assumption, what if we have boxed struct later? Maybe we can have this as some stronger invariant?

MicroOp::Move8 { dst, src } => {
let v = read_u64(fp, src);
write_u64(fp, dst, v);
// The slots need not be 8-aligned (see `Move8`'s doc),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should re-audit similar instructions / APIs, can add a TODO(security)? Also I think HeapMoveFrom8, HeapMoveTo8, HeapMoveTo8Imm have same issue

U256::from_le_bytes(bytes)
}

/// Shared prologue of the tag-dispatched variant-field ops

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this documentation can be more concise focusing on what is returned, and invariants/ error conditions, the fact that is is some prologue is not very relevant

// scratch. Non-overlapping: `dst` is a stack-region
// slot, the field is heap-object bytes.
let (obj_ptr, offset) = variant_field_loc(fp, enum_ref, offsets)?;
std::ptr::copy_nonoverlapping(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and below: let's have // Non-overlapping: reasoning in its own line so it is clearer and closer to actual call?

ref offsets,
size,
} => {
// Fuses the tag-dispatched borrow and the read, no

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Fuses the tag-dispatched borrow and the read, no scratch." reads very AI-slopish, can just remove, instruction name is self documenting?

src,
size,
} => {
// Fuses the tag-dispatched borrow and the write, no

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re comment: can just remove this (instruction name explains) and keep non-overlapping invariant only.

},
Instr::WriteField(_, _, _, val) => {
Instr::ReadFieldChain(_, path, _) => {
b.add_sized(REF_RW_BASE, REF_RW_PER_BYTE, self.chain_terminal_ty(path)?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all chaining ops, I think we better have a cost same as the original chain? Otherwise change to optimization pass is a breaking change and needs feature gating and kills the purpose of moving gas instrumentation to IR? I think we should instrument before optimizations (which iirc we do not do and run after instead)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants